Исходные данные: вы работаете в стартапе, который продаёт продукты питания. Дизайнеры захотели поменять шрифты во всём приложении, а менеджеры испугались, что пользователям будет непривычно. Договорились принять решение по результатам A/A/B-теста. Пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми.
Создание двух групп A вместо одной имеет определённые преимущества. Если две контрольные группы окажутся равны, вы можете быть уверены в точности проведенного тестирования. Если же между значениями A и A будут существенные различия, это поможет обнаружить факторы, которые привели к искажению результатов. Сравнение контрольных групп также помогает понять, сколько времени и данных потребуется для дальнейших тестов.
В случае общей аналитики и A/A/B-эксперимента работайте с одними и теми же данными. В реальных проектах всегда идут эксперименты. Аналитики исследуют качество работы приложения по общим данным, не учитывая принадлежность пользователей к экспериментам.
Цель: разобраться, как ведут себя пользователи мобильного приложения по продаже продуктов питания
Задачи:
1) изучить воронку продаж:
узнать, как пользователи доходят до покупки;
cколько пользователей доходит до покупки;
сколько пользователей «застревает» на предыдущих шагах и на каких именно.
2) исследовать результаты A/A/B-эксперимента:
# импорт необходимых библиотек
import pandas as pd
from datetime import datetime
import matplotlib.pyplot as plt
from plotly import graph_objects as go
from scipy import stats as st
import numpy as np
import math as mth
import warnings
warnings.filterwarnings('ignore')
logs = pd.read_csv('/Users/olgakozlova/Desktop/datasets/logs_exp.csv', sep = '\t')
logs.head()
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
# функция для вывода: инфо, числовое описание, количество пропущенных значений, количество полных дубликатов
def df_overview(df):
print('Общая информация о данных:\n')
df.info()
print('\nЧисловое описание данных:\n')
display(df.describe().T)
print('\nКоличество пропусков:\n')
display(df.isna().sum())
print('\nКоличество полных дубликатов:', df.duplicated().sum())
df_overview(logs)
Общая информация о данных: <class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB Числовое описание данных:
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| DeviceIDHash | 244126.0 | 4.627568e+18 | 2.642425e+18 | 6.888747e+15 | 2.372212e+18 | 4.623192e+18 | 6.932517e+18 | 9.222603e+18 |
| EventTimestamp | 244126.0 | 1.564914e+09 | 1.771343e+05 | 1.564030e+09 | 1.564757e+09 | 1.564919e+09 | 1.565075e+09 | 1.565213e+09 |
| ExpId | 244126.0 | 2.470223e+02 | 8.244339e-01 | 2.460000e+02 | 2.460000e+02 | 2.470000e+02 | 2.480000e+02 | 2.480000e+02 |
Количество пропусков:
EventName 0 DeviceIDHash 0 EventTimestamp 0 ExpId 0 dtype: int64
Количество полных дубликатов: 413
# последние 5 строк таблицы с явными дубликатами
duplicated_logs = logs[logs.duplicated()]
duplicated_logs.tail()
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 242329 | MainScreenAppear | 8870358373313968633 | 1565206004 | 247 |
| 242332 | PaymentScreenSuccessful | 4718002964983105693 | 1565206005 | 247 |
| 242360 | PaymentScreenSuccessful | 2382591782303281935 | 1565206049 | 246 |
| 242362 | CartScreenAppear | 2382591782303281935 | 1565206049 | 246 |
| 242635 | MainScreenAppear | 4097782667445790512 | 1565206618 | 246 |
Таблица logs состоит из 244126 строк и 4 столбцов. Каждая запись в логе — это действие пользователя или событие. Встречаются следующие типы данных: int (3 раза) и object (1 раз). Пропуски в данных отсутствуют.
Согласно документации к данным:
EventName — название события;
DeviceIDHash — уникальный идентификатор пользователя;
EventTimestamp — время события;
ExpId — номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.
Обнаруженные полные дубликаты, составляют меньше 1 % от всех данных и будут удалены:
# удаление явных дубликатов
logs = logs.drop_duplicates().reset_index(drop=True)
# проверка после удаления дублей
logs.shape
(243713, 4)
Переименуем и приведем к нижнему регистр заголовки столбцов:
print(logs.columns)
# переименование столбцов
logs.columns = ['event_type', 'user_id', 'event_timestamp', 'exp_id']
logs.columns
Index(['EventName', 'DeviceIDHash', 'EventTimestamp', 'ExpId'], dtype='object')
Index(['event_type', 'user_id', 'event_timestamp', 'exp_id'], dtype='object')
В столбце 'event_timestamp' дата и время записаны в формате unix timestamp. Переведем данные в формат datetime, выделив их в отдельный столбец 'event_datetime', создадим отдельный столбец с датами 'event_date':
# добавление столбца даты и времени
logs['event_datetime'] = pd.to_datetime(logs['event_timestamp'], unit = 's')
# добавление столбца дат
logs['event_date'] = pd.to_datetime(logs['event_datetime']).dt.date
logs.head()
| event_type | user_id | event_timestamp | exp_id | event_datetime | event_date | |
|---|---|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 | 2019-07-25 04:43:36 | 2019-07-25 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 | 2019-07-25 11:11:42 | 2019-07-25 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 | 2019-07-25 11:28:47 | 2019-07-25 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 | 2019-07-25 11:28:47 | 2019-07-25 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 | 2019-07-25 11:48:42 | 2019-07-25 |
Таблица logs хранит лог сервера с данными о действиях пользователей мобильного приложения по продаже продуктов питания и состоит из 244126 строк и 4 столбцов. Встречаются следующие типы данных: int (3 раза) и object (1 раз).
Пропуски в данных отсутствуют. Найдено и удалено 413 полных дубликатов, которые составляли меньше 1 % от всех данных.
Заголовки столбцов переименованы и приведены к нижнему регистру.
Добавлен столбец даты и времени, а также столбец времени в формате datetime.
# количество событий в логе
events_count = logs['user_id'].count()
print(f"Количество событий в логе: {events_count}")
Количество событий в логе: 243713
# количество уникальных пользователей в логе
users_nunique = logs['user_id'].nunique()
print(f"Количество уникальных пользователей в логе: {users_nunique}")
Количество уникальных пользователей в логе: 7551
# среднее количество событий на пользователя (округление в большую сторону)
print(f"Количество событий на пользователя: {round(events_count/users_nunique,0)}")
Количество событий на пользователя: 32.0
Всего в логе 243713 событий и 7551 уникальный пользователь. В среднем на пользователя приходится 32 события.
# минимальная дата
display(logs['event_datetime'].min())
# максимальная дата
logs['event_datetime'].max()
Timestamp('2019-07-25 04:43:36')
Timestamp('2019-08-07 21:15:17')
В логе хранятся данные с 25.07.2019 по 07.08.2019 включительно.
# столбчатая диаграмма
logs.groupby('event_date').agg({'event_type': 'count'}).plot(kind = 'bar', figsize = (15, 5))
plt.title('Распределение событий во времени')
plt.xlabel('Дата')
plt.ylabel('Количество событий')
plt.xticks(rotation = 30)
plt.show()
Исходные данные не одинаково полные за весь период. Технически в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого — это может «перекашивать данные». Не хватает данных за первую неделю, т.е. с 25.07.2019 до 31.07.2019 включительно. Отбросив эти данные, получим, что на самом деле, располагаем данными с 01.08.2019 по 07.08.2019 включительно. Cкорее всего 01.08.2019 - первый день эксперимента.
event_date = datetime.strptime('2019-07-31', '%Y-%m-%d').date()
# оставим свежие данные с 01.08.2019
logs = logs.query('event_date > @event_date')
logs.head()
| event_type | user_id | event_timestamp | exp_id | event_datetime | event_date | |
|---|---|---|---|---|---|---|
| 2826 | Tutorial | 3737462046622621720 | 1564618048 | 246 | 2019-08-01 00:07:28 | 2019-08-01 |
| 2827 | MainScreenAppear | 3737462046622621720 | 1564618080 | 246 | 2019-08-01 00:08:00 | 2019-08-01 |
| 2828 | MainScreenAppear | 3737462046622621720 | 1564618135 | 246 | 2019-08-01 00:08:55 | 2019-08-01 |
| 2829 | OffersScreenAppear | 3737462046622621720 | 1564618138 | 246 | 2019-08-01 00:08:58 | 2019-08-01 |
| 2830 | MainScreenAppear | 1433840883824088890 | 1564618139 | 247 | 2019-08-01 00:08:59 | 2019-08-01 |
Количество событий в логе после удаления строк составило 240887.
# количество строк (событий) после фильтрации
events_count_after = logs['user_id'].count()
print(f"Количество событий после удаления строк: {events_count_after}")
# потеряные события (строки) в процентах
print(f"Процент потерянных событий после удаления строк: {(events_count - events_count_after)/events_count:.2%}")
print()
# количество уникальных пользователей после фильтрации
users_nunique_after = logs['user_id'].nunique()
print(f"Количество пользователей после удаления строк: {users_nunique_after}")
# потерянные пользователи в процентах
print(f"Процент потерянных пользователей после удаления строк: {(users_nunique - users_nunique_after)/users_nunique:.2%}")
print()
# среднее количество событий на пользователя после фильтрации
print(f"Среднее количество событий на пользователя после удаления строк: {round(events_count_after/users_nunique_after,0)}")
Количество событий после удаления строк: 240887 Процент потерянных событий после удаления строк: 1.16% Количество пользователей после удаления строк: 7534 Процент потерянных пользователей после удаления строк: 0.23% Среднее количество событий на пользователя после удаления строк: 32.0
После фильтрации утеряно небольшое количество событий и пользователей.
Проверим, присутствуют ли в оставшихся данных пользователи из всех трёх экспериментальных групп:
# количество пользователей в экспериментальных группах
logs.groupby('exp_id')['user_id'].nunique()
exp_id 246 2484 247 2513 248 2537 Name: user_id, dtype: int64
Пользователи равномерно распределены между всем группами.
Изначально в логе хранились события за две недели: с 25.07.2019 по 07.08.2019 включительно. Однако построенная столбчатая диаграмма показала 'перекошенность данных', что позволило сделать вывод неполноте данных за весь период. Более старые данные (до 01.08.2019) были отброшены, в логе осталась информация только за вторую неделю (01.08.2019 - 07.08.2019).
После фильтрации было утеряно небольшое количество событий (1.2 %) и пользователей (0.2 %). Все оставшиеся пользователи равномерно распределены между тремя экспериментальными группами.
Посмотрим, какие события есть в логах, и как часто они встречаются:
# встречаемость событий в логах
logs['event_type'].value_counts()
MainScreenAppear 117328 OffersScreenAppear 46333 CartScreenAppear 42303 PaymentScreenSuccessful 33918 Tutorial 1005 Name: event_type, dtype: int64
В логах встречаются следующие события:
1) Отображение пользователю главной страницы (MainScreenAppear)
2) Отображение пользователю страницы предложений (OffersScreenAppear)
3) Отображение пользователю страницы корзины (CartScreenAppear)
4) Страница успешной оплаты (PaymentScreenSuccessful)
5) Туториал (Tutorial).
Наиболее часто встречаются события с отображением пользователю главной страницы приложения.
Посчитаем, сколько пользователей совершали каждое из этих событий:
# количество пользователей совершивших каждое из этих событий
logs_funnel = logs.groupby('event_type')['user_id'].nunique().reset_index().rename(columns = {'user_id': 'user_count'}).sort_values(by = 'user_count', ascending = False)
# добавление строки Все пользователи
logs_funnel = logs_funnel.append({'event_type': 'AllUsers', 'user_count': logs['user_id'].nunique()}, ignore_index=True).sort_values(by = 'user_count', ascending = False)
# процент пользователей, хоть раз совершивших событие
logs_funnel['percent'] = round((logs_funnel['user_count'] / logs['user_id'].nunique() * 100), 1)
logs_funnel
| event_type | user_count | percent | |
|---|---|---|---|
| 5 | AllUsers | 7534 | 100.0 |
| 0 | MainScreenAppear | 7419 | 98.5 |
| 1 | OffersScreenAppear | 4593 | 61.0 |
| 2 | CartScreenAppear | 3734 | 49.6 |
| 3 | PaymentScreenSuccessful | 3539 | 47.0 |
| 4 | Tutorial | 840 | 11.1 |
Можно предположить, что события происходят в следующем порядке: Туториал → Отображение пользователю главной страницы → Отображение пользователю страницы предложений → Отображение пользователю страницы корзины → Страница успешной оплаты.
Туториал не встраивается в цепочку, полученную выше. Многие пользователи просто пропускают этот шаг. Не будем его учитывать при расчете воронки:
# исключение из данных события Туториал
logs = logs[logs['event_type'] != "Tutorial"]
Рассчитаем воронку с учетом изменений количества событий, возможно могло измениться количество всех уникальных пользователей:
# количество пользователей совершивших каждое из этих событий
logs_funnel = logs.groupby('event_type')['user_id'].nunique().reset_index().rename(columns = {'user_id': 'user_count'}).sort_values(by = 'user_count', ascending = False)
# добавление строки Все пользователи без события Туториал
logs_funnel = logs_funnel.append({'event_type': 'AllUsers', 'user_count': logs['user_id'].nunique()}, ignore_index=True).sort_values(by = 'user_count', ascending = False)
# процент пользователей, хоть раз совершивших событие
logs_funnel['percent'] = round((logs_funnel['user_count'] / logs['user_id'].nunique() * 100),1)
logs_funnel
| event_type | user_count | percent | |
|---|---|---|---|
| 4 | AllUsers | 7530 | 100.0 |
| 0 | MainScreenAppear | 7419 | 98.5 |
| 1 | OffersScreenAppear | 4593 | 61.0 |
| 2 | CartScreenAppear | 3734 | 49.6 |
| 3 | PaymentScreenSuccessful | 3539 | 47.0 |
Количество уникльных пользователей сократилось до 7530. Построим воронку событий:
# воронка событий plotly
fig = go.Figure(
go.Funnel(
y = logs_funnel['event_type'],
x = logs_funnel['user_count'],
textinfo = "value+percent previous+percent initial",
)
)
fig.update_layout(
title={
'text': 'Воронка событий',
'y':0.9,
'x':0.5,
'xanchor': 'center',
'yanchor': 'top'
}
)
fig.show()
По воронке событий наглядно видно, какая доля пользователей проходит на следующий шаг воронки (от числа пользователей на предыдущем).
Больше всего пользователей теряется на шаге 'Отображение пользователю главной страницы' - около 38 %.
От первого события до оплаты доходит примерно 47 % пользователей.
1) Сделано предположение, что события происходят в следующем порядке: Туториал → Отображение пользователю главной страницы → Отображение пользователю страницы предложений → Отображение пользователю страницы корзины → Страница успешной оплаты. Так как многие пользователи пропускают шаг туториал, при расчете воронки он не учитывается;
2) По построенной воронке событий видно, что:
1.5 % всех пользователей не доходят до главного экрана, вероятно, тут имеет место техническая ошибка;
больше всего пользователей теряется на шаге 'Отображение пользователю главной страницы' - около 38 %;
от первого события до оплаты доходит примерно 47 % пользователей.
Посмотрим, сколько пользователей в каждой экспериментальной группе:
# количество пользователей в каждой экспериментальной группе
trials = logs.groupby('exp_id').agg({'user_id': 'nunique'})
display(trials)
# круговая диаграмма
with plt.style.context('seaborn'):
trials.plot.pie(y = 'user_id',
title="Распределение пользователей по экспериментальным группам", legend=False, autopct='%1.1f%%', startangle=0
)
| user_id | |
|---|---|
| exp_id | |
| 246 | 2483 |
| 247 | 2512 |
| 248 | 2535 |
Между группами пользователи распределены равномерно, в каждой из трех групп примерно по 2500 пользователей.
Построим воронку событий по экспериментальным группам:
# количество пользователей в каждой экспериментальной группе, аналог таблице выше, но транспонированная
all_users_by_exp = logs.pivot_table(columns = 'exp_id', values = 'user_id', aggfunc = 'nunique').reset_index()
all_users_by_exp.columns = ['event_type', '246', '247', '248']
# воронка событий в разбивке по 3 экспериментам
funnel = logs.pivot_table(index = 'event_type', columns = 'exp_id', values = 'user_id', aggfunc = 'nunique').reset_index()
funnel.columns = ['event_type', '246', '247', '248']
funnel = funnel.sort_values(by = '246', ascending=False)
display(funnel)
# объединение воронки и таблицы с общим количеством пользователей
funnel_with_allusers = pd.concat([funnel, all_users_by_exp], axis = 0).sort_values(by = '248', ascending = False).reset_index(drop=True)
funnel_with_allusers.loc[funnel_with_allusers['event_type'] == "user_id", ['event_type']] = 'AllUsers'
funnel_with_allusers
| event_type | 246 | 247 | 248 | |
|---|---|---|---|---|
| 1 | MainScreenAppear | 2450 | 2476 | 2493 |
| 2 | OffersScreenAppear | 1542 | 1520 | 1531 |
| 0 | CartScreenAppear | 1266 | 1238 | 1230 |
| 3 | PaymentScreenSuccessful | 1200 | 1158 | 1181 |
| event_type | 246 | 247 | 248 | |
|---|---|---|---|---|
| 0 | AllUsers | 2483 | 2512 | 2535 |
| 1 | MainScreenAppear | 2450 | 2476 | 2493 |
| 2 | OffersScreenAppear | 1542 | 1520 | 1531 |
| 3 | CartScreenAppear | 1266 | 1238 | 1230 |
| 4 | PaymentScreenSuccessful | 1200 | 1158 | 1181 |
# воронка событий plotly
fig = go.Figure()
fig.add_trace(go.Funnel(
name = 'exp_246',
y = funnel_with_allusers['event_type'],
x = funnel_with_allusers['246'],
textinfo = "value+percent previous+percent initial"))
fig.add_trace(go.Funnel(
name = 'exp_247',
y = funnel_with_allusers['event_type'],
x = funnel_with_allusers['247'],
textinfo = "value+percent previous+percent initial"))
fig.add_trace(go.Funnel(
name = 'exp_248',
y = funnel_with_allusers['event_type'],
x = funnel_with_allusers['248'],
textinfo = "value+percent previous+percent initial",
textposition = "inside"))
fig.update_layout(
title={
'text': 'Воронка событий по трём экспериментам',
'y':0.9,
'x':0.5,
'xanchor': 'center',
'yanchor': 'top',
}
)
fig.show()
Самое популярное событие - показ пользователю главной страницы (MainScreenAppear). В группе 246 его совершили 2450 пользователей (99 %), в 247: 2476 пользователей (99 %), в 298: 2493 пользователя (98 %).
Проведем проверку на присутствие пользвателей сразу в нескольких группах.
# уникальные пользователи, состоящие в нескольких группах одновременно
logs.groupby('user_id')['exp_id'].agg('nunique').reset_index().query('exp_id > 1')
| user_id | exp_id |
|---|
Пользователи присутствующие сразу в нескольких группах не обнаружены.
Есть 2 контрольные группы для А/А-эксперимента, чтобы проверить корректность всех механизмов и расчётов. Проверим, находят ли статистические критерии разницу между выборками 246 и 247 для всех событий. Для этого проведем z-тест. Сформулируем нулевую и альтернативную гипотезы:
Н0 (Нулевая гипотеза): между выборками 246 и 247 нет отличий в доле пользователей, совершивших событие Х;
Н1 (Альтернативная гипотеза): между выборками 246 и 247 есть отличия в доле пользователей, совершивших событие Х.
Примем критический уровень статистической знаимости (alpha) равным 0.05.
# воронка без строки все уникальные пользователи
funnel = logs.pivot_table(index = 'event_type', columns = 'exp_id', values = 'user_id', aggfunc = 'nunique').sort_values(by = 248, ascending = False)
# добавление столбца объединенная контрольная группа 246+247
funnel['246+247'] = funnel[246] + funnel[247]
display(funnel)
| exp_id | 246 | 247 | 248 | 246+247 |
|---|---|---|---|---|
| event_type | ||||
| MainScreenAppear | 2450 | 2476 | 2493 | 4926 |
| OffersScreenAppear | 1542 | 1520 | 1531 | 3062 |
| CartScreenAppear | 1266 | 1238 | 1230 | 2504 |
| PaymentScreenSuccessful | 1200 | 1158 | 1181 | 2358 |
# количество пользователей в каждой экспериментальной группе
trials = trials.reset_index()
trials = trials.append({'exp_id': '246+247', 'user_id': 4995}, ignore_index=True) # добавление строки 246+247
trials = trials.set_index(trials.columns[0])
display(trials)
| user_id | |
|---|---|
| exp_id | |
| 246 | 2483 |
| 247 | 2512 |
| 248 | 2535 |
| 246+247 | 4995 |
Создадим функцию для проверки отличия между группами:
# исследование отличий для групп 246 и 247 по событиям
def z_test (exp1, exp2, event, alpha):
successes1 = funnel.loc[event, exp1]
successes2 = funnel.loc[event, exp2]
trials1 = trials.loc[exp1, 'user_id']
trials2 = trials.loc[exp2, 'user_id']
# пропорция успехов в обеих группах:
p1 = successes1/trials1
p2 = successes2/trials2
# пропорция успехов в комбинированном датасете:
p_combined = (successes1 + successes2) / (trials1 + trials2)
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials1 + 1/trials2))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('Для групп {} и {} по событию {} p-значение: {p_value:.2f}'.format(exp1, exp2, event, p_value=p_value))
if (p_value < alpha):
print("Отвергаем нулевую гипотезу: между долями есть значимая разница")
else:
print("Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными")
# вызов функции z-test для 246 и 247 групп
for event in funnel.index:
z_test(246, 247, event, 0.05)
print()
Для групп 246 и 247 по событию MainScreenAppear p-значение: 0.75 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Для групп 246 и 247 по событию OffersScreenAppear p-значение: 0.25 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Для групп 246 и 247 по событию CartScreenAppear p-значение: 0.23 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Для групп 246 и 247 по событию PaymentScreenSuccessful p-значение: 0.11 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Во всех тестах групп 246 и 247 не получилось отвергнуть нулевую гипотезу. Это говорит о том, что между долями нет разницы и разбиение на 2 контрольные группы для А/А-эксперимента работает корректно. Переходим к А/В-тестированию.
Аналогично проведем тесты для группы с измененным шрифтом (248). Сравним результаты с каждой из контрольных групп в отдельности по каждому событию. Сравним результаты с объединённой контрольной группой.
Сформулируем нулевую и альтернативную гипотезы для сравнения первой контрольной группы 246 и группы с измененным шрифтом 248:
Н0 (Нулевая гипотеза): между выборками 246 и 248 нет отличий в доле пользователей, совершивших событие Х;
Н1 (Альтернативная гипотеза): между выборками 246 и 248 есть отличия в доле пользователей, совершивших событие Х.
Критический уровень статистической знаимости (alpha) примем равным 0.05.
# вызов функции z-test для 246 и 248 групп
for event in funnel.index:
z_test(246, 248, event, 0.05)
print()
Для групп 246 и 248 по событию MainScreenAppear p-значение: 0.34 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Для групп 246 и 248 по событию OffersScreenAppear p-значение: 0.21 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Для групп 246 и 248 по событию CartScreenAppear p-значение: 0.08 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Для групп 246 и 248 по событию PaymentScreenSuccessful p-значение: 0.22 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Для групп А (246) и В (248) по всем событиям разница в долях пользователей не обнаружена.
Нулевая и альтернативная гипотезы для сравнения второй контрольной группы 247 и группы с измененным шрифтом 248:
Н0: между выборками 247 и 248 нет отличий в доле пользователей, совершивших событие Х;
Н1: между выборками 247 и 248 есть отличия в доле пользователей, совершивших событие Х.
# вызов функции z-test 247 и 248
for event in funnel.index:
z_test(247, 248, event, 0.05)
print()
Для групп 247 и 248 по событию MainScreenAppear p-значение: 0.52 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Для групп 247 и 248 по событию OffersScreenAppear p-значение: 0.93 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Для групп 247 и 248 по событию CartScreenAppear p-значение: 0.59 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Для групп 247 и 248 по событию PaymentScreenSuccessful p-значение: 0.73 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
У групп А (247) и В (248) статистической значимости между долями нет.
Проведем сравнение между объединенной контрольной группой А (246+247) и группой В (248). Нулевая и альтернативная гипотезы:
Н0: между выборками 246+247 и 248 нет отличий в доле пользователей, совершивших событие Х;
Н1: между выборками 246+247 и 248 есть отличия в доле пользователей, совершивших событие Х.
# вызов функции z-test объединенная контрольная группа и 248
for event in funnel.index:
z_test('246+247', 248, event, 0.05)
print()
Для групп 246+247 и 248 по событию MainScreenAppear p-значение: 0.35 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Для групп 246+247 и 248 по событию OffersScreenAppear p-значение: 0.45 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Для групп 246+247 и 248 по событию CartScreenAppear p-значение: 0.19 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Для групп 246+247 и 248 по событию PaymentScreenSuccessful p-значение: 0.61 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
При сравнении объединенной контрольной группы (246+247) и группы с измененным шрифтом (248) различия в долях пользователей, совершивших событие Х, не выявлены.
В исследовании проведено 16 проверок, уровень значимости был принят равным 0.05. Чтобы снизить вероятность ложнопозитивного результата при множественном тестировании гипотез, применяют разные методы корректировки уровня значимости для уменьшения FWER. Из-за простоты решения применим поправку Бонфферони:
print('Поправка Бонфферони для 16 сравнений с уровнем значимости 0.05:', 0.05/16)
Поправка Бонфферони для 16 сравнений с уровнем значимости 0.05: 0.003125
Не будем повторять тесты с использованием нового уровня значимости 0.003125, так как значение p_value во всех случаях достаточно большое - всегда с запасом выше 0.05, а значит будет точно выше 0,003125.
После применения поправки Бонфферони результат остается неизменным: для всех событий статистической разницы между долями нет.
1) В каждой экспериментальной группе примерно по 2500 пользователей, т.е пользователи между группами распределены равномерно;
2) По всем событиям для контрольных групп (246 и 247) статистические отличия между долями пользователей не выявлены;
3) По всем событиям для контрольной группы А (246) и группы с измененным шрифтом В (248) статистической значимости между долями нет;
4) По всем событиям для контрольной группы А (247) и группы с измененным шрифтом В (248) статистической значимости между долями нет;
5) По всем событиям для объединенной контрольной группы (246+247) и группы с измененным шрифтом В (248) статистической значимости между долями нет;
6) Использование поправки Бонфферони не изменило результатов тестов;
7) Таким образом, можно сделать вывод, что между контрольными группами А и группой с измененным шрифтом В отсутствуют отличия.
При подготовке данных к анализу:
удалено 413 полных дубликатов, которые составляли меньше 1 % от всех данных;
заголовки столбцов переименованы и приведены к нижнему регистру;
добавлен столбец даты и времени, а также столбец времени в формате datetime.
При изучении и проверке данных:
данные до 01.08.2019 года были отброшены, в логе осталась информация только за вторую неделю (01.08.2019 - 07.08.2019);
после фильтрации утеряно небольшое количество событий (1.2 %) и пользователей (0.2 %).
При изучении воронки событий:
сделано предположение, что события происходят в следующем порядке: Туториал → Отображение пользователю главной страницы → Отображение пользователю страницы предложений → Отображение пользователю страницы корзины → Страница успешной оплаты. Так как многие пользователи пропускают шаг туториал, при расчете воронки он не учитывается;
выявлено, что 1.5 % всех пользователей не доходят до главного экрана, вероятно, тут имеет место техническая ошибка;
выявлено, что больше всего пользователей теряется на шаге 'отображение пользователю главной страницы' - около 38 %. Стоит обратить на это внимание и выяснить, почему эти пользователи не доходят шага 'отображение пользователю страницы предложений';
выявлено, что от первого события до оплаты доходит примерно 47 % пользователей.
При изучении результатов эксперимента:
определено, что пользователи между группами распределены равномерно (примерно по 2500 пользователей на группу);
можно сделать вывод, что между контрольными группами А и группой с измененным шрифтом В отсутствуют отличия, то есть изменение шрифта в приложении статистически значимого влияние на конверсию ни на одном уровне не оказывает;
рекомендовано, не вводить изменение шрифта.